声明:本专题为Joshua Bloch所著《Effiective Java》的笔记。
James Gosling(Java之父):“我很希望10年前就拥有这本书。可能有人认为我不需要任何Java方面的书籍,但是我需要这本书。”
这本书完全称得上是编程神作,无关编程语言。
它唯一的缺点就是中文翻译有些瑕疵,但如果发现了这些瑕疵,就说明你的技术又深入了一步。
这本书是在我刚接触编程时获得的,但那时读起来确实不知所云。
在我独自做过javase,javaee的项目后,再来读仍觉得它难以读懂。
直到我学过了数据结构、设计模式、spring、springboot等等,有了更多的项目经验,再来了解它,才发现原来自己曾经在编程上遇到的多数疑问,它早已有解答。幸得阅之于学生时代,少走些弯路。
第2章 创建和销毁对象
本章的主题是创建和销毁对象:何时以及如何创建对象,何时以及如何避免创建对象,如何能够保证它们能够适时地销毁,以及如何管理对象销毁之前必须进行的各种清理动作。
第1条:考虑使用静态工厂方法替代构造方法
什么是静态工厂方法
某个类允许客户端获得其实例的传统方式是提供一个公共的(public)构造方法。
除此之外,还有一种方法是必须要考虑的,即类可以提供一个公共的(public)静态工厂方法(static factory method),它是一个返回类的实例的静态方法。
下面是一个来自Boolean(基本类型boolean的包装类)的简单例子。
1 | /** |
静态工厂方法的优点
静态工厂方法与构造方法不同的第一大优点在于,它们有名称。如果构造方法的参数无法确切地描述被返回的对象,那么具有适当名称的静态工厂方法会更容易使用,并且生成的客户端代码也将更易于阅读。
例如,
BigInteger
的构造方法BigInteger(int. int. Random)
返回的是一个可能为素数的值,如果使用一个名为BigInteger.probablePrime
的静态方法来表示,显然更为清楚。(1.4的发行版本中增加了这个方法。)一个类只能有一个给定签名(方法名和参数列表)的构造方法,编程人员都知道如何避免这个限制:提供两个不同参数列表顺序的构造方法。实际上这是一个非常糟糕的主意。这样的API,使用者永远记不清该用哪个构造方法。阅读和使用这些构造方法的人只有在有参考文档时才知道这些代码的作用。
由于静态方法有名称,所以它们不受上述的限制。当一个类需要多个相同签名的构造方法时,用静态工厂方法代替构造方法,并且慎重地选择方法名称以突出它们之间的区别。
静态工厂方法与构造方法不同的第二大优点在于,不必在每次调用时都创建一个新对象。
这允许不可变类(详见第17条)可以使用预先构建的实例,或者将构造好的实例缓存起来,并反复分配它们以避免创建不必要对象。
Boolean.valueOf(boolean)
很好地说明了这种技术。这种技术类似于Flyweight
模式。如果程序经常创建相同对象,并且创建对象的代价很高,则这项技术可以极大地提升性能。静态工厂方法能够为重复地调用返回同一对象,这样有助于类总能严格控制在某个时刻哪些实例存在。这种类被称为实例受控的(instance-controlled)。编写实例受控类有以下原因:
确保这个类是单例的(Singleton)(见第3条)或者不可实例化的(详见第4条)。
它还可确保不可变的类(详见第17条)不会存在两个相同的实例,即当且仅当
a==b
时,e.equals(b)
,我们可以使用==
替代equals(Object)
,从而提升程序运行速度而不影响正确性。Enum
类型正使用了这一点。
静态工厂方法与构造方法不同的第三大优点在于,它们可以返回其返回类型的任一子类的对象。这样,我们在选择返回的对象时就有了更大的灵活性。
这种灵活性的一个应用是,API可以返回对象,而不必把对象的类变成公共的,以这种方式隐藏实现类会使API更加简洁。这种技术适用于基于接口的框架(详见第20条),其中的接口为静态工厂方法提供了自然返回类型。
这里的”自然“(原文为natural),并非一种专用术语,而是指在定义接口时,所希望的返回类型。
在Java 8之前,接口不能有静态方法。按照惯例,一个名为
Type
的接口的静态工厂方法放在名为Types
的不可实例化的伴随类(companion class)(详见第4条)中。例如,java集合框架(Java Collections Framework)的接口有45个实现类,分别提供了不可修改的集合、同步集合等等。几乎所有的实现类都是通过在一个不可实例化的类(java.util.Collections)中的静态工厂方法导出的。所有静态工厂方法返回对象的类都是非公共(nonpublic)的。
Collections Framework API 的规模要比单独导出45 个公共类要小得多,一个API方便了所有实现类。不仅是 API 的数量的减少,还包括概念重量(conceptual weight):编程者使用API所必须掌握的概念的数量和难度。编程者知道了要返回的对象恰好有接口指定的API,就不再需要阅读实现类的文档。此外,这种静态工厂方法要求编程者通过一个该接口类的变量来引用返回的对象,而不是实现类,这是一种良好的习惯(详见第 64 条)。
从 Java 8 开始,取消了接口不能包含静态方法的限制,所以不必再提供一个不可实例化的伴随类。很多公共的静态成员应该放在这个接口之中。但是,请注意,将这些静态方法的大部分实现代码放在单独的包私有类中仍然是必要的,这是因为 Java 8 要求所有接口的静态成员都是公共的。Java 9 允许私有静态方法,但静态属性和静态成员类仍然需要设为公共。
静态工厂方法与构造方法不同的第四大优点在于,返回对象的类可以根据输入参数的不同而不同。声明的返回类型的任何子类都是允许的。为了提升软件的可维护性和性能,返回对象的类也可能随每次发行版本而不同。
例如,
EnumSet
类(详见第 36 条),它没有公共构造方法,只有静态工厂方法。在OpenJDK 的实现中,是根据底层枚举类型的大小返回两个子类中的一个的实例:如果大多数枚举类型具有 64 个或更少的元素,静态工厂方法将返回一个RegularEnumSet
实例,返回一个long
类型;如果枚举类型具有六十五个或更多元素,则将返回一个JumboEnumSet
实例,返回一个long
类型的数组。这两个实现类的存在对于客户是不可见的。 如果
RegularEnumSet
不再为小枚举类型提供性能优势,则可以在未来版本中将其淘汰,而不会产生任何不良影响。同样,未来的版本可能会为了提高性能而添加EnumSet
的第三个或第四个实现。 编程者既不知道也不需要关心他们从工厂返回的对象的类别;他们只关心它是EnumSet
的一些子类。静态工厂方法与构造方法不同的第五大优点在于,在编写包含该方法的类时,返回的对象的类不需要存在。这种灵活的静态工厂方法构成了服务提供者框架(Service Provider Framework)的基础,例如 JDBC(Java 数据库连接,Java Database Connectivity)API。服务提供者框架,是提供者实现服务的系统,使实现对于编程者可用,将编程者与实现进行分离。
服务提供者框架中有三个基本组:服务接口(Service Interface),提供者注册 API(Provider Registration API),服务访问 API(Service Access API)。以及一个可选的第四个组件:服务提供者接口(Service Provider API)。
服务接口:定义了需要实现的服务。
提供者注册 API:提供者实现服务后,向管理者注册的API。
服务访问 API:编程者获得服务的API。
服务提供者接口:它描述了一个生成服务接口实例的工厂对象。在没有服务提供者接口的情况下,实现必须通过反射进行实例化(详见第 65 条)。
以下为一个完整服务提供者框架的简单实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71// 服务接口
public interface UserService {
void login();
void register();
}
// 服务接口具体实现类
public class UserServiceImpl implements UserService {
public void login() {
System.out.println("登陆成功");
}
public void register() {
System.out.println("注册成功");
}
}
// 服务提供者接口
public interface ServiceProvider {
UserService getUserService();
}
// 服务提供者接口具体实现类
public class ServiceProviderImpl implements ServiceProvider {
static {
ServiceManager.registerProvider("GxkOrd", new ServiceProviderImpl());
}
public UserService getUserService() {
return new UserServiceImpl();
}
}
// 服务管理类
public class ServiceManager {
private ServiceManager() {
}
private static final Map<String, ServiceProvider> SERVICE_PROVIDER_MAP = new ConcurrentHashMap<>();
public static void registerProvider(String name, ServiceProvider provider) {
SERVICE_PROVIDER_MAP.put(name, provider);
}
public static UserService getUserService(String providerName) {
ServiceProvider provider = SERVICE_PROVIDER_MAP.get(providerName);
if (provider == null) {
throw new IllegalArgumentException("No provider registered with name = " + providerName);
}
return provider.getUserService();
}
}
// 测试
public class Test {
public static void main(String[] args) {
try {
Class.forName("包名.ServiceProviderImpl");
UserService userService = ServiceManager.getUserService("登陆注册");
userService.register();
userService.login();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
服务提供者框架模式有许多变种。 例如,服务访问 API 可以向客户端返回比提供者提供的更丰富的服务接口,这是桥接(Bridge)模式。
依赖注入框架(详见第 5 条)可以被看作强大的服务提供者。
从 Java 6 开始,jdk包含一个通用的服务提供者框架java.util.ServiceLoader
,所以你不需要,一般也不应该自己编写(详见59条)。JDBC
没有使用ServiceLoader
,因为前者早于后者。
静态工厂方法的缺点
静态工厂方法的主要缺点在于,类如果不含公共的或者受保护的构造器,就不能被实例化。对于公共的静态工厂方法所返回的非公共类,也同样如此。
例如,不可能将Collections Framework中任何方便的实现类子类化。但这也许因祸得福,因为它鼓励了程序员使用复合(composition)而不是继承(详见第 18 条),而且它是不可变的(详见第 17 条)。
静态工厂方法的第二个缺点是,程序员很难找到它们。它们不像构造方法那样在 API 文档中突出,因此很难实例化一个提供了静态工厂方法而没有构造方法的类。可能有一天,Javadoc 工具会注意到静态工厂方法。同时,你可以通过将注意力吸引到类或接口文档中的静态工厂以及遵守通用的命名约定来减轻这个问题。下面是一些静态工厂方法的常用名称:
from —— 一个类型转换方法,它接受单个参数并返回此类型的相应实例,例如:
Date d = Date.from(instant);
of —— 一个聚合方法,接受多个参数并返回该类型的实例,并把他们合并在一起,例如:
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
valueOf —— from 和 to 更为详细的替代方式,例如:
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
instance 或 getInstance —— 返回一个由其参数 (如果有的话) 描述的实例,但不能说它具有相同的值,例如:
StackWalker luke = StackWalker.getInstance(options);
create 或 newInstance —— 与 instance 或 getInstance 类似,但该方法保证每个调用返回一个新的实例,例如:
Object newArray = Array.newInstance(classObject, arrayLen);
getType —— 与 getInstance 类似,但是如果在工厂方法中不同的类中使用。Type 是工厂方法返回的对象类型,例如:
FileStore fs = Files.getFileStore(path);
newType —— 与 newInstance 类似,但是如果在工厂方法中不同的类中使用。Type 是工厂方法返回的对象类型,例如:
BufferedReader br = Files.newBufferedReader(path);
type —— getType 和 newType 简洁的替代方式,例如:
List<Complaint> litany = Collections.list(legacyLitany);
总之,静态工厂方法和公共构造方法都有它们的用途,并且了解它们的相对优点是值得的。但通常,静态工厂更可取,因此避免在没有考虑静态工厂方法的情况下使用公共构造方法。
第2条:当构造方法参数过多时使用builder模式
静态工厂方法和构造方法都有一个局限性:它们不能很好地扩展很多可选参数。请考虑一个表示包装食品外面显示营养成分的标签类,这些标签有几个必需的属性(每份的含量,每罐的含量以及每份的卡路里等),还有20多个可选的属性(总脂肪、饱和脂肪、反式脂肪等)。大多数产品的几个可选属性都为非零值。
对于这样的类,我们该选用什么样的构造方法或者静态工厂方法?
方法1:重叠构造方法模式
多数编程者习惯采用重叠构造方法(telescoping constructor)模式:先提供一个必要参数的构造方法,然后第二个构造方法有一个可选参数,第三个构造方法有两个可选参数,依此类推。最后一个构造方法中包含所有可选参数。
以下有一个示例,为了简单起见,它只显示了四个可选属性。
1 | public class NutritionFacts { |
当想要创建一个实例时,可以使用包含所有要设置的参数的最短参数列表的构造方法:
1 | NutritionFacts cocaCola = |
调用这个构造方法通常要设置许多你本不想设置的参数,在上述情况下,我们就为 fat 属性设置了 0 值。在只有六个参数的情况下,看起来可能也不算太糟糕,但随着参数数量的增加,它很快就会失控。
简而言之,虽然重叠构造方法模式是有效的,但当有很多参数时,就会变得很难用,而且不易读。
方法2:JavaBeans模式
当在构造方法中遇到许多可选参数时,另一种选择是 JavaBeans 模式:调用一个无参数的构造方法来创建对象,然后调用 setter 方法来设置每个必需的参数和可选参数。
1 | public class NutritionFacts { |
这种模式没有重叠构造方法模式的缺点,虽然有点冗长,但创建实例很容易,并且编写的代码也易于阅读:
1 | NutritionFacts cocaCola = new NutritionFacts(); |
不幸的是,JavaBeans 模式本身有着严重的缺陷。
首先,多个setter方法的调用,分割了对象的构造过程,在这个构造过程中 JavaBean 很可能处于不一致的状态。类也失去了通过检查构造参数的有效性来保证一致性的能力。在不一致的状态下尝试使用对象,可能会导致错误(这并非一种代码上的bug,因此很难调试)。
另一个与此相关的缺点是,JavaBeans 模式排除了让类不可变的可能性(见第17条),这就需要编程者花费额外精力来确保线程安全。
通过在对象构建完成时手动”冻结“对象,并且不允许它在”解冻“之前使用,可以弥补这些缺点,但这种方法十分笨拙,在实践中很少使用。 而且由于编译器无法确保编程者在使用对象之前调用了freeze()
方法(冻结对象),在运行时会很容易报错。
方法3:Builder模式
幸运的是,还有第三种选择,它既保证了像重叠构造方法模式一样的安全性又具有 JavaBean 模式一般的可读性。这就是Builder模式:
不直接获得所需的对象,而是先使用所有必需的参数调用构造方法 (或静态工厂)获得一个 builder 对象。然后调用 builder 对象的一种类似于setter的方法来设置每个可选参数。最后,客户端调用一个无参的 build 方法来生成对象,该对象通常是不可变的。Builder 通常是它所构建的类的一个静态成员类 (详见第24条)。以下是它在实践中的示例:
1 | public class NutritionFacts { |
NutritionFacts
类是不可变的,所有的参数默认值都在一个地方。builder 的 setter 方法返回 builder 本身,这样调用就被链接了起来,从而生成一个流畅的 API。下面是构造代码的示例:
1 | NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8) |
这样,构造代码很容易编写,且更易于阅读。 Builder 模式模拟 Python 和 Scala 中的具名的可选参数。
为了简洁起见,省略了有效性检查。可在builder的构造函数和其他方法中检查参数有效性,以快速检测出无效参数。 可在build方法调用的构造方法中检查包含多个参数的不变性。为了确保这些不变性不受攻击,从 builder 复制参数后要对对象属性进行检查(见第50条)。如果检查失败,则抛出 IllegalArgumentException 异常(见第 72条),异常信息指示哪些参数无效(见第75条)。
Builder模式非常适合类层次结构。 使用平行层次的 builder,每个嵌套在相应的类中。抽象类有抽象的builder,具体的类有具体的 builder。例如,代表各种比萨饼的根层次结构的抽象类:
1 | public abstract class Pizza { |
请注意,Pizza.Builder
是一个带有递归型参数( recursive type parameter)(见第30条)的泛型类型。它与抽象的self
方法一起,允许方法链在子类中也能正常使用,而不需要强制类型转换。由于Java缺少自我类型,习惯上称这种变通的方法为模拟自我类型(simulated self-type)。
这里有两个具体的 Pizza 的子类,其中一个代表纯正纽约风格的披萨,另一个是半圆形烤乳酪披萨。前者有一个所需的尺寸参数,而后者则需要指定酱汁在里面还是在外面:
1 | public class NyPizza extends Pizza { |
注意,每个子类 builder 中的build
方法被声明为返回正确的子类:NyPizza.Builder
的build
方法返回NyPizza
,而Calzone.Builder
中的build
方法返回Calzone
。子类中的方法被声明为返回的类型,是在父类中声明的返回类型的子类型,这种技术称为协变返回类型(covariant return typing)。它允许创建时使用这些builder,而不需要强制转换。
以下为创建对象的代码示例(为了简洁起见,假设枚举常量已静态导入):
1 | NyPizza pizza = new NyPizza.Builder(SMALL) |
相对于构造方法,builder还有一个微小的优势:builder 可以有多个可变参数,因为参数都是在它自己的方法中指定的。另外,builder 可以将多个方法的参数聚合到单个属性中,正如前面的addTopping
方法那样。
Builder 模式非常灵活。单个 builder 可以重复使用来构建多个对象。builder 的参数可以在构建方法的调用之前进行调整,以改变创建的对象。builder 可以在创建对象时自动填充一些属性,例如每次创建对象时增加的序列号。
Builder 模式也有缺点。
为了创建对象,首先必须创建它的 builder。虽然创建这个 builder 的成本在实践中不太可能被注意到,但在性能很关键的情况下可能会出现问题。
而且,builder 模式比重叠构造方法模式更冗长,因此只有在有足够的参数时才值得使用它,比如四个或更多。但请记住,务必未雨绸缪。如果在将来会添加更多的参数,请尽快将构造方法(或静态工厂)切换到 builder,当类演化到参数数量失控的时候,过时的构造方法或静态工厂会处于很鸡肋的处境。因此,最好从一开始就创建一个 builder。
总而言之,当发现类的构造方法或静态工厂的参数已经足够多时,特别是在许多参数是可选的或相同类型的情况下,Builder 模式是一个不错的选择。因为Builder的创建代码比重叠构造方法更容易读写,而且比 JavaBeans 更安全。
第3条:使用私有构造方法或枚类实现 Singleton属性
单例是指一个仅实例化一次的类。单例对象通常表示无状态对象,如函数(见第24条) 或一个本质上唯一的系统组件。让一个类成为单例会使测试变得十分困难,因为除非它实现了一个充当其类型的接口,否则不可能模拟实现它。
有以下两种常见的方法来实现单例。两者都是通过私有化构造方法和导出公共静态成员来来提供对这个唯一实例的访问。
第一种方法,使用final修饰的公共成员变量:
1 | public class Elvis { |
私有构造方法只调用一次,来初始化 INSTANCE
对象的属性。没有公共的或受保护的构造方法,保证了全局的唯一性:一旦 Elvis 类被初始化,就永远只存在一个实例。客户端的任何行为都不能改变这一点,但需要注意的是:有特权的客户端,可以使用AccessibleObject.setAccessible
方法,以反射方式调用私有构造方法(详见第 65 条)。如果需要防止此攻击,可以修改构造方法,使其在被请求创建第二个实例时
抛出异常。
第二种方法,使用静态工厂方法:
1 | public class Elvis { |
所有对getInstance
的调用都会返回相同的对象引用,而且不会创建其他的 Elvis 实例(当然,上一种方法需要注意的点依然存在)。
第一种方法(公共成员变量)的主要优点是,API 可以明确表示该类是一个单例:公共成员变量是 final 的,所以它总是包含相同的对象引用。 第二个好处是它更简单。
第二种方法(静态工厂)的一个优点是,它可以随着你的想法灵活地改变,无论该类是否为单例的,都不必修改其 API。静态工厂方法返回唯一的实例,但是可以修改,比如,修改为返回调用它的每个线程的单独实例。 第二个好处是,如果你的应用程序需要它,可以编写一个泛型单例工厂(generic singleton factory )(详见第30 条)。使用静态工厂的最后一个优点是方法引用可以用supplier
(java 1.8的新特性),例如 Elvis::instance 等同于 Supplier
创建一个使用这两种方法的单例类(第 12 章),仅仅实现Serializable
接口是不够的。为了维护单例的保证,声明所有的实例属性为transient
,并提供一个readResolve
方法(详见第 89条)。否则,每当序列化实例被反序列化时,就会创建一个新的实例(在上述例子中,会出现新的 Elvis 实例)。为了防止这种情况发生,将这个readResolve
方法添加到 Elvis 类:
1 | private Object readResolve() { |
实现单例的第三种方法是,声明单一元素的枚举类:
1 | public enum Elvis { |
这种方式跟公共成员变量方法很类似,但更简洁。枚举也无偿地提供了序列化机制,而且即使是在面对复杂的序列化或反射攻击的时候,也不用担心它会被多次实例化。单一元素的枚举让人感觉有点不自然,但不可否认,它是实现单例的最佳方式。注意,如果单例必须继承Enum
以外的父类,那么就不能使用这种方法。(当然,Enum
可以实现接口)
第4条:使用私有构造方法实现非实例化
有时候,你可能需要编写一个只包含静态方法和静态属性的类。 这种类的名声很不好,因为有些人会在面向对象语言中滥用这样的类来编写过程化的程序。
尽管如此,它们确实有着特有的用途。
比如像
java.lang.Math
或java.util.Arrays
,它们可以用来把基本类型的值或数组类型上的相关方法组织起来。也可以像
java.util.Collections
的方式,把实现特定接口的对象使用静态方法(包括工厂方法,详见第 1 条)进行分组。(从 Java 8 开始,你也可以将这些方法放在接口中,如果是你编写了接口并可以进行修改。)最后,还可以利用这种类把 final 类上的方法进行分组,因为final类不存在子类。
通过将它声明为抽象类,来强制其实现非实例化显然是行不通的。因为该类可以被子类化,子类依然可以被实例化。而且,它会误导使用者以为该类是为继承而设计的(详见第 19 条)。不过,有一个简单的方法来确保非实例化:当类不包含显式构造方法时,会生成一个默认构造方法,因此可以通过包含一个私有构造方法来实现类的非实例化:
1 | public class UtilityClass { |
由于显式构造方法是私有的,所以在类之外不可访问到。AssertionError
异常不是严格要求的,但它提供了一种保证,以防在类中意外地调用构造方法。它保证类在任何情况下都不会被实例化。这个用法有点违合,好像构造方法就是设计成不能调用的一样。因此,像上述代码一样,添加注释是种非常明智的做法。
这种习惯有一个副作用,阻止了类的子类化。因为所有的构造方法都必须显式或隐式地调用父类构造方法,而子类没有可访问的父类构造方法来调用。
第5条:依赖注入优于硬连接资源(hardwiring resources)
许多类依赖于一个或多个底层资源。例如,拼写检查器依赖于字典。常见的一种方式,是将此类声明为静态成员变量(详见第 4 条):
1 | // 静态成员变量使用不当,不灵活、不可测试! |
另一种常见方式,是将它们实现为单例(详见第 3 条):
1 | // 单例使用不当,不灵活、不可测试! |
这两种方法都不令人满意,因为它们只为对象提供了一本字典。在实际中,每种语言都有自己的字典,特殊的字典被用于特殊的词汇表。另外,使用专门的字典来进行测试也是可取的。想当然地认为一本字典就足够了,这是十分不可取的。
可以通过使dictionary
属性设置为非final
,并添加一个方法来更改现有拼写检查器中的字典,从而让拼写检查器支持多个字典,但是在并发环境中,这很笨拙且容易出错,不可行。静态成员变量和单例不适用于那些行为被底层资源参数化的类。
若类支持创建多个实例的 (如上述例子中的SpellChecker
),每个实例都需要使用者指定资源(如上述例子中的dictionary
)。满足这一需求的简单模式是在创建新实例时将资源传递到构造方法中。这是依赖注入(dependency injection)的一种形式:字典是拼写检查器的一个依赖项,当它创建时被注入到拼写检查器中。
1 | // 依赖注入提供了灵活性和可测试性 |
依赖注入模式非常简单,许多编程者用了很多年,却不知道它有一个名字。 虽然拼写检查器的例子中只有一个资源(字典),但其实依赖项注入可以使用任意数量的资源和任意依赖图。 它保持了不变性(详见第17条),因此多个客户端可以共享依赖对象(假设客户需要相同的底层资源)。 依赖注入同样适用于构造方法,静态工厂(详见第1条)和 builder 模式(详见第2条)。
该模式的一个非常有用的变型是将资源工厂传递给构造方法。 工厂是可以重复调用以创建类型实例的对象。 这种工厂体现了工厂方法模式(Factory Method pattern)。 Java 8 中引入的Supplier<T>
接口非常适合代表工厂。 在输入上采用Supplier<T>
的方法通常应该使用有界通配符类型(bounded wildcard type)(详见第31条)来约束工厂的类型参数,以允许使用者传入工厂,创建指定类型的任何子类型。 例如,下面是一个使用者提供的工厂生成 tile 的方法:
1 | Mosaic create(Supplier<? extends Tile> tileFactory) { ... } |
尽管依赖注入极大地提高了灵活性和可测试性,但它可能使大型项目变得混乱,这些项目通常包含数千个依赖项。使用依赖注入框架(如:Dagger、Guice或Spring)可以消除这些混乱(这些框架的使用超出了本书的内容)。但请注意,为手动依赖注入而设计的 API 非常适合使用这些框架。
总之,不要使用单例或静态成员变量来实现依赖于其行为影响类的一个或多个底层资源的类,并且不要让类直接创建这些资源。而是应该将资源或工厂传递给构造方法(或静态工厂或 builder 模式)。这种称为依赖注入的方法将极大地增强类的灵活性、可重用性和可测试性。
第6条:避免创建不必要的对象
一般来说,每次需要时一个对象时,最好考虑重用以前的对象而不是创建一个具有相同功能新对象。重用既快速,又流行。如果对象是不可变的(见第17条),它总是可以被重用。
作为一个极端的反面例子,请考虑以下语句:
1 | String s = new String("bikini"); // DON'T DO THIS! |
语句每次被执行时都会创建一个新的String实例,但是这些对象的创建都不是必需的。String 构造方法的参数("bikini")
本身就是一个String实例,它与构造方法创建的所有对象的功能相同。如果这种用法发生在循环中,或者在频繁调用的方法中,就可以创建很多个毫无必要的String实例。
改进后的版本如下:
1 | String s = "bikini"; |
这个版本使用单个String实例,而不是每次执行时创建一个新实例。此外,它可以保证,对于同一虚拟机上中的运行的代码,只要它们包含相同的字符串字面量,对象就能被重用。
通过使用静态工厂方法(见第1条),可以避免创建不需要的对象。例如,工厂方法Boolean.valueOf(String)
比构造方法Boolean(String)
更可取(后者在 Java 9 中已被弃用)。构造方法每次调用时都必须创建一个新对象,而工厂方法则没有这种要求,实际上也不会这样做。除了重用不可变对象,如果知道它们不会被修改,还可以重用可变对象。
一些对象的创建比其他对象的创建要昂贵得多。如果要重复使用这样一个「昂贵的对象」,建议将其缓存起来以便重复使用。不幸的是,当创建这样一个对象时并不总是很直观明显的。假设你想写一个方法来确定一个字符串是否是一个有效的罗马数字。以下是使用正则表达式完成此操作时最简单方法:
1 | // Performance can be greatly improved! |
这个实现的问题在于它依赖于String.matches
方法。虽然String.matches
是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能临界的情况下重复使用。其问题是它在内部为正则表达式创建一个Pattern 实例,并且只使用它一次,之后它就被自动GC。创建 Pattern 实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。
为了提高性能,作为类初始化的一部分,将正则表达式显式编译为一个 Pattern 实例(不可变),缓存它,并在isRomanNumeral
方法的每个调用中重复使用相同的实例:
1 | // Reusing expensive object for improved performance |
如果经常调用,isRomanNumeral
的改进版本的性能会显著提升。为不可见的 Pattern 实例创建静态 final 修饰的属性,并给它一个名字,这个名字比正则表达式本身更具可读性。
如果包含isRomanNumeral
方法的改进版本的类被初始化,但该方法从未被调用,则 ROMAN 属性则没必要初始化。在第一次调用isRomanNumeral
方法时,可以通过延迟初始化( lazily initializing)属性(见第83条)来延迟初始化,但一般不建议这样做。延迟初始化常常会导致实现复杂化,而对性能没有改进(见第67条)。
当一个对象是不可变的时,很明显它可以被安全地重用,但是在其他情况下,它远没有那么明显,甚至是违反直觉的。考虑适配器(adapters)的情况,也称为视图(views)。一个适配器是这样一个对象:它把功能委托一个后备对象(backing object),从而为后备对象提供一个可替代的接口。由于适配器除了后备对象外,没有其他状态信息,因此不需要为某个给定对象创建多个适配器的实例。
例如,Map 接口的keySet
方法返回 Map 对象的 Set 视图,包含 Map 中的所有 key。粗看起来,似乎每次调用keySet
都必须创建一个新的 Set 实例,但是对给定 Map 对象的keySet
的每次调用都返回相同的 Set 实例。 尽管返回的 Set 实例通常是可变的,但是所有返回的对象在功能上都是相同的:当其中一个返回的对象发生变化时,所有其他对象也都变化,因为它们全部是由一个 Map 实例支持的。 虽然创建keySet
视图对象的多个实例基本上是无害的,却也是没必要的。
另一种创建不必要的对象的方法是自动装箱(autoboxing),它允许程序员混用基本类型和基本类型的包装类,按需要自动装箱和拆箱。自动装箱虽然使基本类型和基本类型的包装类之间的差别模糊不清,但不会完全消除。它们有微妙的语义区别和不太细微的性能差异(见第61条)。考虑下面的方法,它计算所有正整数的总和。要做到这一点,程序必须使用long
类型,因为int
类型不足以保存所有正整数的总和:
1 | // Hideously slow! Can you spot the object creation? |
这个程序的结果是正确的,但由于写错了一个字符,运行的结果要比实际慢很多。变量sum
被声明成了Long
而不是long
,这意味着程序构造了大约 231 不必要的Long
实例(大约每次往Long
类型的sum
变量中增加一个long
类型构造的实例),把sum
变量的类型由Long
改为long
,运行时间从 6.3秒降低到了0.59 秒。这个教训很明显:优先使用基本类型而不是装箱的基本类型,要当心无意识的自动装箱。
这一条不应该被误解为暗示对象创建是昂贵的,应该避免创建对象。相反,使用构造方法创建和回收小的对象是非常廉价,构造方法只会做很少的显示工作,尤其是在现代 JVM 实现上。创建额外的对象以增强程序的清晰度、简单性和功能性,这通常是件好事。
相反地,通过维护自己的对象池(object pool)来避免对象创建并不是一个好主意,除非池中的对象非常重量级。对象池的典型例子就是数据库连接池。建立连接的成本非常高,因此重用这些对象是有意义的。但是,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代 JVM 实现具有高度优化的垃圾收集器,它们的性能远胜于创建轻量级对象池。
这个条目的对应面是第50条的防御性复制(defensive copying)。现在说:「当你应该重用一个现有的对象时,不要创建一个新的对象」,而第50条说:「当你应该创建一个新的对象时,不要重用现有的对象。」请注意,提倡防御性复制时,因重用对象所付出的代价,要远远大于创建重复的对象。未能在需要的情况下实施防御性复制会导致潜在的错误和安全漏洞;而不必要地创建对象只会影响程序的风格和性能。